Repository navigation
feat(accounts)!: enforce change feed changed_paths and per-reader idempotency_key (#1647) - #1805
Conversation
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean AdCP 3.3 account change feed (#1647) with no blocking or medium findings.
What I checked (per reviewer):
account_change_feed.pykey-echo is fail-closed:_echoes_key_to(L255-261) returns False unless record isAttributedAccountChangewith origin.kind=="adcp" and originator==reader;project_account_change(L280-281) stripsidempotency_keyfor plain dicts, unattributed models, and mismatched/anonymous readers. PrincipalRef equality includes tenant, so cross-tenant collisions strip too._requires_changed_pathsfail-closes to True on None/unparseable input; exempts 3.2, admits 3.3/future majors.- Dispatch centralized in
create_tool_caller(MCP + A2A), runs before model_dump/context-echo/version-conversion, raises VALIDATION_ERROR in all modes. - Webhook timeout/retry path unchanged; projection is sync CPU with typed-error on refusal.
- Semver: BREAKING CHANGE footer + migration row present; submodule-level change, API snapshot unchanged.
Decision trace: No critical/high/medium findings (row 1, 4, 5, 6 don't fire). gated_paths false (row 2 n/a). high_risk true only via src/adcp/server/mcp_tools.py (modified), but with zero medium findings row 5 doesn't fire and no deletion means row 3 doesn't fire. No no-auto-approve team match (row 7 n/a). Zero medium findings (row 8 n/a). Falls through to row 9 → approve.
…mpotency_key (#1647) AdCP 3.3 account change feed rules, applied where the SDK emits records: - list_account_changes dispatch refuses a page carrying an adcp / seller_operator record without non-empty changed_paths (VALIDATION_ERROR at /changes/N/changed_paths, any validation mode, handlers on 3.3 or later). - Every record loses idempotency_key unless it is an adcp record attributed, off the wire, to the calling principal (attribute_account_change); unattributed records fail closed. - WebhookSender.prepare_account_change_recorded accepts the change record (with subscriber_id), checks it, and never carries its key. - New adcp.account_change_feed helpers: json_pointer, ROOT_POINTER, validate_account_change, attribute_account_change, project_list_account_changes_response, account_change_recorded_payload, mirror_creative_revision (creative seller_revision -> resource_revision). BREAKING CHANGE: a list_account_changes handler returning an adcp or seller_operator record without changed_paths now fails the call, and idempotency_key is stripped from every record not attributed to the caller via adcp.account_change_feed.attribute_account_change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a521a86 to
f119e57
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass, no findings.
Checked:
- New
adcp.account_change_feedmodule enforces AdCP 3.3 change-feed rules:changed_pathsrequired on adcp/seller_operator records, per-readeridempotency_keyprojection. - Key-echo gate (account_change_feed.py:255-261, strip at :280-281) fails closed — plain dicts, unattributed models, tenant mismatches and anonymous callers all lose the key; covered by the echo matrix and fail-closed tests.
- Semver signal correct:
feat!:+ BREAKING CHANGE footer + MIGRATION_v9_to_v10.md row. - Import layering clean (domain path, no
_generated), forward-compat files untouched. - Version-pathway handled: projection runs before model_dump/version-conversion so attribution survives; 3.2-handler path keeps paths optional but still strips keys. Test present.
- CI gates armed.
Decision table: no critical/high/medium findings. gated_paths is false; review_decision is APPROVED. high_risk true but only src/adcp/server/mcp_tools.py (modified) with no medium-or-higher concern, so row 5 does not fire; no deletions (row 3 n/a). No author team gate. Prior decision was approve. Falls through to row 9 → approve.
Spec
AdCP 3.3 (
core/account-change.json), for sellers declaring the experimentalaccount.change_feed:origin.kindisadcporseller_operatorMUST carry non-emptychanged_paths(RFC 6901 pointers;""for a whole-resource create, delete or purge).idempotency_keyis echoed only onadcprecords, and only to the principal that sent the originating request. Every other reader gets the record without it, andaccount.change_recordednotifications MUST NOT carry it.seller_revisiononlist_creatives; a creative change record'sresource_revisionmirrors it.The Scope3 ESA already sends
changed_paths; the SDK now matches and enforces it.Design
New module
adcp.account_change_feed(its own public address; not added to theadcptop-level facade, so the API snapshot is unchanged):ROOT_POINTER,json_pointer(*tokens)~and/).validate_account_change(change)AccountChangeRecordError(aValueErrorwith.pointer) for missing or emptychanged_pathsonadcp/seller_operatorrecords, oridempotency_keyon a non-adcprecord.AttributedAccountChange,attribute_account_change(change, originator)AccountChangesubclass whose originatingPrincipalRefis a pydantic private attribute, so it never reaches the wire.originatorcan be aPrincipalRef, a principal id, or the mutating request'sToolContext.project_account_change/project_list_account_changes_responseaccount_change_recorded_payload(change, subscriber_id=...)mirror_creative_revision(change, creative)seller_revisionintoresource_revisionin the same JSON type. Removes it when the creative has none, and checks the bounds.Responses (
list_account_changes).create_tool_callerserves both MCP and A2A. Right after the handler returns, while attribution is still on the model instances, it projects the page for the caller (ToolContext.tenant_id+caller_identity). Projection runs beforemodel_dump, context echo, the response enhancer and version conversion.adcprecord attributed to the caller keepsidempotency_key. Every other record loses it: plain dicts, unattributed models, records with a different principal or tenant, and anonymous callers. If the SDK can't show the reader is the originator, the key is stripped.adcprecord carrying a key has it dropped, not refused.adcp/seller_operatorrecord withoutchanged_pathsrefuses the page withVALIDATION_ERROR(field/changes/N/changed_paths,details.side = "response") and an error log on the server. This applies in every validation mode, for handlers whose application version is 3.3 or later. A handler pinned to 3.2, wherechanged_pathsis optional, isn't checked.if/then. The SDK check makes that refusal deterministic and points at the exact field. A buyer reconciles its cache fromchanged_paths, so a record without it is worse than a failed read: the buyer would retry a failed read, but would silently mis-reconcile from a bad record. The feed is an experimental 3.3-only surface with no SDK 9 adopters, and the one known adopter (ESA) already conforms.Notifications.
WebhookSender.prepare_account_change_recorded/send_account_change_recordednow also accept the change record itself, plussubscriber_id=(required) andthrough_cursor=. A record is detected byorigin/repair, which the notification schema forbids. When given a record, the SDK:AccountChangeRecordError);account_change_recorded_payload, which never copies the record'sidempotency_key;Notification-shaped payloads pass through unchanged. Their
idempotency_keyis the delivery key, which the SDK can't tell apart from a copied request key; the docstring warns against copying one._notification_envelope.pyneeded no change.seller_revision. It already passes throughlist_creativesunharmed: the model round-trips it, and server serialization at 3.3 keeps it. For buyers below 3.3, the existing 3.3 → 3.2 conversion drops it and records the loss. The same conversion drops a record'sidempotency_keyfor 3.2 buyers. Tests pin both behaviors. Thestable_creative_ids/platform_idwork (#1638) is untouched.Tests
tests/test_account_change_feed.py(41 tests):create_tool_callerwith model and dict responses under strict validationsubscriber_id, non-conformant record)seller_revisionpassthrough at 3.3 and its drop at 3.2mirror_creative_revisionDocs
docs/adcp-3.3-sdk-inventory.md: thelist_creativesseller_revisionrow and theaccount-change.jsonrow are marked done.MIGRATION_v9_to_v10.md: new row covering refused pages, stripped keys and the record-accepting notification preparation.Closes #1647
BREAKING CHANGE: a list_account_changes page with an adcp or seller_operator record that lacks a non-empty changed_paths is refused with VALIDATION_ERROR, and idempotency_key is stripped from every record not served to its originating principal; see MIGRATION_v9_to_v10.md.
🤖 Generated with Claude Code